Spring Boot/Reactで実装したWebアプリをCDKでECSにデプロイする
こんにちは、CX事業本部のうらわです。
今回はバックエンドはSpring Boot、フロントエンドはReact(SPA)のWebアプリケーションをAWS CDK(以下、CDK)でAmazon ECSデプロイしてみました。フロントエンドはS3/Cloud Frontで配信するのではなく、Spring Bootの一部としてデプロイします。
本記事ではSpring BootやReactで実装するアプリ自体のコードの説明は重要な点のみです。全てのコード例は以下のGitHubを参照ください。
作業環境
$ sw_vers ProductName: Mac OS X ProductVersion: 10.15.7 BuildVersion: 19H15 $ java --version openjdk 11.0.11 2021-04-20 OpenJDK Runtime Environment AdoptOpenJDK-11.0.11+9 (build 11.0.11+9) OpenJDK 64-Bit Server VM AdoptOpenJDK-11.0.11+9 (build 11.0.11+9, mixed mode) $ gradle --version ------------------------------------------------------------ Gradle 6.8.3 ------------------------------------------------------------ Build time: 2021-02-22 16:13:28 UTC Revision: 9e26b4a9ebb910eaa1b8da8ff8575e514bc61c78 Kotlin: 1.4.20 Groovy: 2.5.12 Ant: Apache Ant(TM) version 1.10.9 compiled on September 27 2020 JVM: 11.0.11 (AdoptOpenJDK 11.0.11+9) OS: Mac OS X 10.15.7 x86_64 $ node -v v14.15.4 $ npm -v 6.14.10 $ yarn -v 1.22.10
Spring Bootでバックエンド(API)を実装する
以下のチュートリアルの前半を参考にして/api/employees
でEmployeeデータを得ることができるAPIを作成します。
フロントエンドはReactで実装するため、バックエンドでは/
へのリクエストでindex.html
を返却するのみのコントローラを用意しておきます。
package com.example.payroll; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class HomeController { @GetMapping("/") public String index() { return "forward:/index.html"; } }
また、APIへのリクエストはベースURLを変更しておきます。これについてはstackoverflowで色々な方法が議論されています。
今回はBaseController
というクラスを作成しこのクラスを継承したコントローラに記述されたリクエストに全て/api
がprefixにつくようにします(上記のstackoverflownの回答を参考にしています)。
package com.example.payroll; import org.springframework.web.bind.annotation.RequestMapping; @RequestMapping("api") public abstract class BaseController {}
Reactでフロントエンド(SPA)を実装する
Spring BootアプリのフロントエンドにReactを組み込みます。こちらは以下の記事を参考にさせていただきました。
Spring BootアプリにCreate React Appを導入する
frontend
というディレクトリでReactアプリの雛形を作成します。create-react-app
を利用します。
$ npx create-react-app frontend --template typescript
package.json
にbuild:docker
とpostbuild
というコマンドを追加します。build:docker
は名前の通りdockerでビルドする時に使用します。
"scripts": { "start": "react-scripts start", "build": "react-scripts build", "build:docker": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "postbuild": "node ./postbuild.js" },
postbuild
はローカルでのbuild
実行後、以下のようなスクリプトを実行してビルド結果をSpring Boot側のディレクトリにコピーします。Spring Bootは先ほど作成したHomeController.java
でここでコピーしたReactアプリのビルド結果に含まれるindex.html
を返却してくれます。
※ npm scriptsはprexxxx
やpostxxxx
という名前でコマンドを定義しておくと、xxxx
というコマンドの実行前後に自動で実行してくれます(参考)。
const path = require('path'); const fs = require('fs-extra'); const BUILD_DIR = path.join(__dirname, './build'); const PUBLIC_DIR = path.join(__dirname, '../backend/src/main/resources/public'); fs.emptyDirSync(PUBLIC_DIR); fs.copySync(BUILD_DIR, PUBLIC_DIR);
Spring BootアプリへのAPIリクエストなど、上記以外のReactアプリのコードは冒頭に記載したGitHubを参照ください。
CDKでデプロイする
cdk
というディレクトリ作成して実装します。今回はTypeScriptを使用します。
$ mkdir cdk && cd cdk $ cdk init -l typescript
Dockerイメージのpush
まずはSpring Boot/ReactアプリのDockerイメージを格納するECRリポジトリを作成しデプロイします。
import * as cdk from '@aws-cdk/core'; import * as ecr from '@aws-cdk/aws-ecr'; export class EcrRepoStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const region = props?.env?.region; const accountId = props?.env?.account; new ecr.Repository(this, 'ecr-repo', { repositoryName: 'spring-boot-react-app-repo', }); new cdk.CfnOutput(this, 'ecr-repo-uri', { value: `${cdk.Aws.ACCOUNT_ID}.dkr.ecr.${cdk.Aws.REGION}.amazonaws.com`, }); new cdk.CfnOutput(this, 'ecr-login-password', { value: `aws ecr get-login-password --region ${region} \ | docker login --password-stdin --username AWS \ "${accountId}.dkr.ecr.${region}.amazonaws.com"`, }); } }
$ yarn cdk deploy ecr-repo-stack
今回使用するDockerfileは以下です。マルチステージビルドでSpring BootとReactそれぞれビルドします。
FROM node:14-alpine AS frontend WORKDIR /tmp COPY ./frontend ./frontend WORKDIR /tmp/frontend RUN yarn install RUN yarn build:docker FROM openjdk:11-jdk-slim AS builder WORKDIR /tmp COPY ./backend ./backend WORKDIR /tmp/backend COPY --from=frontend /tmp/frontend/build /tmp/app/src/main/resources/public RUN ./gradlew build FROM openjdk:11-jdk-slim WORKDIR /app COPY --from=builder /tmp/backend/build/libs/payroll-0.0.1-SNAPSHOT.jar . ENTRYPOINT ["java", "-jar", "payroll-0.0.1-SNAPSHOT.jar"]
まずはdocker loginで対象リポジトリにpushできるようにしておきます。
$ aws ecr get-login-password --region ap-northeast-1 | docker login --password-stdin --username AWS "<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com"
つづいて、ビルド・タグ付けしてpushします。
$ docker build -t spring-boot-react-app . $ docker tag <image_id> <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/spring-boot-react-app-repo $ docker push <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/spring-boot-react-app-repo
ECSのデプロイ
以下のCDKのコードでデプロイします。@aws-cdk/aws-ecs-patternsというハイレベルコンストラクトを利用してさくっとALBとECSをデプロイします。
import * as cdk from '@aws-cdk/core'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; import * as ecsPatterns from '@aws-cdk/aws-ecs-patterns'; import * as ecr from '@aws-cdk/aws-ecr'; export class EcsAppStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // パブリックサブネット const vpc = new ec2.Vpc(this, 'app-vpc', { maxAzs: 2, cidr: '10.1.0.0/16', subnetConfiguration: [ { subnetType: ec2.SubnetType.PUBLIC, name: 'public', cidrMask: 24 }, ], }); const cluster = new ecs.Cluster(this, 'app-cluster', { vpc: vpc, capacity: { instanceType: new ec2.InstanceType('t2.small'), minCapacity: 2, }, }); // 今回は諸事情により起動タイプEC2 const appTaskDef = new ecs.TaskDefinition(this, 'app-task-def', { compatibility: ecs.Compatibility.EC2, }); const repo = ecr.Repository.fromRepositoryName( this, 'ecr-repo', 'spring-boot-react-app-repo', ); // イメージをECRから取得する appTaskDef .addContainer('app-container', { image: ecs.ContainerImage.fromEcrRepository(repo), cpu: 256, memoryLimitMiB: 256, dockerLabels: { app: 'spring-boot-react-app' }, logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'App' }), }) .addPortMappings({ containerPort: 8080 }); // ハイレベルコンストラクト const appService = new ecsPatterns.ApplicationLoadBalancedEc2Service( this, 'app-service-with-alb', { cluster: cluster, serviceName: 'spring-boot-react-app', desiredCount: 2, taskDefinition: appTaskDef, }, ); appService.service.addPlacementStrategies( ecs.PlacementStrategy.spreadAcross( ecs.BuiltInAttributes.AVAILABILITY_ZONE, ), ); } }
$ yarn cdk deploy ecs-app-stack
デプロイが完了すると、URLが表示されるのでアクセスしてみます。
Employeesにアクセスすると、Spring Bootで実装したバックエンドのAPIにGETリクエストを送り、ユーザー一覧を取得して表示します。
なお、SPAなので画面遷移はブラウザで完結しています(/settings
への画面遷移はサーバへのリクエストが発生しない)。
おわりに
バックエンドはSpring Boot、フロントエンドはReactという構成でECSにアプリケーションをデプロイしてみました。Thymeleaf等のテンプレートエンジンではなく、自分が使い慣れたReactでフロントエンドを実装できるので個人的にはお気に入りです。Spring Boot自体まだ使い慣れていないため、この構成でいろいろ試してみようと思います。